feat(prerecorded): add AsyncTranscriber for asyncio callers - #227
Conversation
…asses Prepare for the asyncio transcriber. This commit does not change behavior. `streaming/v3/_base.py` holds the sync/async-agnostic core for its two clients. `prerecorded/v2` now has the same file, so `Transcriber` and `AsyncTranscriber` share one base instead of one copying the other. - `_base.py`: `TranscriptFields` holds the 27 response accessors. `_BaseTranscript` and `_BaseTranscriber` hold the state contract and the config handling. `_raise_for_status`, `is_url`, `config_from_response`, and `TERMINAL_STATUSES` hold the logic both transports repeat. - `transcript.py`: `Transcript` inherits `_BaseTranscript` and supplies the response through `_response()`. The 27 accessors and their repeated None checks are gone, so the file drops 190 lines. - `client.py`: `Transcriber` and `_TranscriberImpl` inherit `_BaseTranscriber` and resolve a per-call config through `_resolve_config`. - Root `client.py`: header and pool-limit construction move into `_build_headers` and `_build_limits`, so `AsyncClient` gets the same user-agent, auth header, and keepalive. `api.py` is untouched. `_base._raise_for_status` currently serves the asyncio transport only. Switching `api.py` over is a one-line follow-up per function, left out here to keep this diff additive. Tests: `pytest tests/unit` gives 388 passed, 3 failed. The 3 failures need `pyaudio` and also fail on the base branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #80. `Transcriber.transcribe_async` returns a `concurrent.futures.Future`. That future is not awaitable, and `.result()` blocks the event loop. An asyncio caller must therefore route transcription through a thread pool, which the hardware bounds. `AsyncTranscriber` provides the same API as `Transcriber`, with coroutines. It takes the same `TranscriptionConfig`. Its result has the same fields. It needs one thread. The new modules follow the layout of their threaded twins, one public class per module, layered `async_transcript <- async_client`: - `prerecorded/v2/async_transcript.py`: `AsyncTranscript`. - `prerecorded/v2/async_client.py`: `AsyncTranscriber`. - `prerecorded/v2/async_api.py`: an asyncio version of each function in `api.py`, raising the same error per endpoint through `_base`. - `async_client.py` at root holds `AsyncClient`, which wraps an `httpx.AsyncClient`. It sits beside `Client`, because `prerecorded/v2` and `sync/v1` both use the root client. There is no process-wide default: the pool belongs to the event loop that first used it, so a global pool fails on a second `asyncio.run()`. Behavior notes: - `AsyncTranscript` has the same fields as `Transcript`. Only the methods that call the API are coroutines. The transcriber provides `get_by_id` and `delete_by_id`, because the async transcript needs the transcriber's pool. - An upload sends a path or a file object in chunks. A worker thread reads each chunk. The request sets `Content-Length` when the size is known. - `transcribe_group` and `submit_group` keep the input order, limit concurrent work to `max_concurrency`, and report every error. The threaded group methods discard errors when `return_failures` is not set. - LeMUR remains synchronous only. `LemurSource` now accepts an `AsyncTranscript`. The tests avoid every pytest-httpx API added after 0.20, because the pinned httpx tox envs resolve pytest-httpx back that far. The group tests stub `async_api.create_transcript` rather than the HTTP layer, since no pytest-httpx version in the matrix can delay a mocked response. Tests: 45 tests in `tests/unit/test_async_transcriber.py`. The full suite gives 433 passed, 3 failed on httpx 0.22, 0.24, and 0.28, and under both pydantic paths. The 3 failures need `pyaudio` and also fail on the base branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1d1b473 to
9332da7
Compare
Live production verificationSame method as #226, plus a leg for the asyncio work. One shared transcript created up front, every read path deep-compared as normalized JSON. Baseline is Three legs, all green:
Byte-identical across both parity legs: all 27 accessors that commit 1 moved into Asyncio-only results: upload returns an https URL; 15 transcripts created and deleted per run, audio capped to the first 30s via 🤖 Generated with Claude Code |
Adds
aai.AsyncTranscriber, the asyncio counterpart ofTranscriber. Every APIcall is a coroutine, so transcriptions run concurrently on one thread. Closes #80.
Rebased onto master now that #226 has landed. Two commits, reviewable separately:
_base.py— no behavior change.TranscriptFields(the 27 responseaccessors),
_BaseTranscript,_BaseTranscriber. Both concurrency modelsinherit them, which is the base-class sharing refactor(transcriber): move prerecorded transcription into prerecorded/v2 #226 deferred.
transcript.pydrops 190 lines.
async_transcript.py,async_client.py,async_api.pyinprerecorded/v2, plusAsyncClientbeside the rootClient. Sameone-class-per-module layout as the threaded twins. Every
_*Implsurvives.Four deliberate divergences from the threaded API
AsyncClient. Anhttpx.AsyncClientpool belongs to the loopthat first used it, so a global one dies on a second
asyncio.run().AsyncTranscriberowns one per instance; pass a client to share a pool.get_by_id/delete_by_idare on the transcriber, not classmethods on thetranscript, because the async transcript needs the transcriber's pool.
settles, or
return_failures=Truegives(transcripts, errors). They alsokeep input order and cap in-flight work at
max_concurrency(default 8).Content-Lengthwhen the size is known, so httpx skipschunked encoding. Verified on httpx 0.19, 0.22, 0.24, 0.28.
Testing
433 passed, 3 failed (
pyaudio, also failing on base). Green on httpx 0.22 /0.24 / 0.28 and under both pydantic paths. mypy + ruff clean. #226's
backwards-compat test still passes. 45 new tests, including two that pin the
shared bases and two that prove real concurrency.
Not verified: no live-API run yet, and no Python 3.8 interpreter (tox starts at
3.9).
Known gap
api.pystill inlines its own error strings, so the per-endpoint messages existin both
api.pyandasync_api.py._base._raise_for_statusis ready; switchingapi.pyover is one line per function. Left out to keep the diff additive — happyto fold it in.